%unimsg 0 design/patch-vocabulary/v0 -- The argument for @vocab/patch/v0: why four separate needs turned out to want -- one artefact, why a patch carries what it displaced, and what building the -- applier found — which included a real bug the design's own result digest -- caught before anything was written. -- -- Written against @vocab/design/v0. It follows docs/56, which parked a version -- control system and identified this as the thing underneath it. patch-vocabulary @design/patch-vocabulary/v0 { title "The patch vocabulary: the artefact four needs were asking for, and the digest that keeps it honest" status :proposal about -> @vocab/patch/v0 follows -> @design/native-revision-shape/v0 at 2026-09-04 -- =================================================================== -- THE ASK -- =================================================================== the-ask "docs/56 ended by saying that four separate wants were asking for one thing and that the repository did not have it: an undo stack is a list of invertible structural changes semantic history is a query over the changes between versions a merge driver is a function on changes an intensional edit is a change that carries the rule that made it AND THAT tools/umsgdiff PRODUCED A REPORT AND NOT A PATCH — text on a terminal, which can be read once and never applied, inverted, composed or refused. This document is the design of the thing it should have been producing." what-was-built [ | artefact is | "vocab/subjects/patch-v0.umsg" "the vocabulary" | "internal/patch" "paths, application, inversion and the document encoding — the vocabulary's application block in Go, shared so the emitter and the applier cannot drift" | "tools/umsgdiff -patch" "the emitter: the same structural walk, keeping the displaced value rather than rendering and discarding it" | "tools/umsgpatch" "the applier: apply, invert, and check. It refuses before writing and verifies after" | "examples/patch-tenancy-clause.umsg" "a real change to a document already in this repository, which anybody can verify against it" | "internal/diff" "the structural walk, moved out of tools/umsgdiff so the undo stack uses the same one. Computing it twice in two places is how two answers to one question begin" | "internal/undo" "an undo and redo stack made of patches. THE USE CASE THE VOCABULARY WAS DESIGNED FOR" | "tools/undospike" "the falsification below, run rather than argued" | "implementations/docs/editor-undo.umsg" "the integration guide, for an editor in any language: what state to hold, what to check and when, the coalescing numbers, and a five-step self-test needing no reference implementation" ] -- =================================================================== -- THE DECISIONS -- =================================================================== decision-one-a-patch-names-what-it-applies-to "BY DIGEST, AND THAT IS THE DECISION THE REST HANGS FROM. `patch -p1` will apply a diff to a file that has drifted, using context and a fuzz factor, and produce something plausible and wrong. It has to: a line patch cannot identify what it is applying to. A DOCUMENT HERE IS NAMED BY ITS CONTENT, so there is nothing to be fuzzy about. The patch states the exact bytes it was made from, and an applier holding anything else refuses — before writing. Tested: handing the tenancy patch a Japanese article produces `this patch is for another document: it names 26985cc9e702… and this one is 33e4abc739f6…` and exits without touching anything. THE DIGEST IS OVER THE CANONICAL ENCODING AND NOT THE TEXT, which buys something a line patch cannot have at any price: a patch survives its base being reformatted. docs/56 measured that reformat at 1,392 changed lines, every one of which would have defeated a context diff." decision-two-every-operation-carries-what-it-displaced "SO THE PATCH INVERTS, and an undo stack is this list read backwards and nothing else. JSON PATCH MAKES BOTH HALVES OPTIONAL and pays for it twice: `remove` discards what it removed, so a patch cannot be undone, and `test` is a separate operation somebody has to remember to emit, so a patch does not check unless asked. Here :was IS the precondition and IS what the inverse needs, which is one field doing both jobs. THE COST IS THAT A PATCH IS ROUGHLY TWICE THE CHANGE IT DESCRIBES, and it is stated in the vocabulary rather than discovered. For an editor's undo stack that is the point; for a transport it is what compression is for." decision-three-the-result-digest-makes-the-applier-checkable "AND IT EARNED ITS PLACE ON THE FIRST REAL TEST, which is the most useful thing in this document. THE EMITTER WAS WRONG. Given a sequence with an insertion at [1] and a removal at [2], it emitted them in the order it discovered them — and after the insertion, index 2 is a different element. The patch was malformed in a way that would have corrupted a document quietly. NOTHING CAUGHT THAT BY REVIEW. The result digest caught it mechanically, on the first run, with the message `operation 1 (drop doc.items[2]): :was is not the value that is here` — because :was is checked before every write and the whole patch is refused when it does not match. THE FIX IS NOW IN THE EMITTER, and stated as a comment where the next person will look: for one sequence, emit modifications first while it still has the old shape, then drops HIGHEST INDEX FIRST, then adds LOWEST FIRST. Nested sequences are safe because their operations apply before any of the outer sequence's shape changes." the-emitter-refuses-to-write-a-patch-it-cannot-apply "A SECOND CONSEQUENCE OF THE SAME PROPERTY, and it is why the bug above could not have escaped. tools/umsgdiff applies every patch it builds BEFORE writing it, and falls back to one wholesale replacement at the root when the fine-grained version does not verify — reporting that it did so, with the reason. A SILENT FALLBACK WOULD HAVE BEEN WORSE THAN THE BUG. It would have produced correct, enormous patches for ever, and nobody would have known the fine-grained path had stopped working." decision-four-a-path-is-a-string-and-not-a-reference "BECAUSE THE SPECIFICATION SAYS A REFERENCE CANNOT DO THIS. `a path segment is a map key, so a row of a table cannot be addressed: rows are sequence elements and have no names`. A PATCH MUST ADDRESS POSITIONS — a document's third paragraph is a real place a change happens — so the path is a string in a stated grammar, resolved against exactly one document. It is deliberately not the kernel's reference, which is inert, resolves against a document SET, and addresses only things the format names." decision-five-identity-beats-position-where-there-is-one "`[3]` means the third element right now; `[@parties]` means that clause wherever it has moved to. The emitted example uses the second form — `tenancy-agreement.content[@parties].name[0].text` — because the tenancy agreement's clauses carry identifiers. THIS IS THE IDEA CRDTs REST ON, borrowed without the machinery: a lasting identity per sequence member, which this format already has and an author can simply use. And it is the specification's own advice for references — `key it if you will reference it` — turning out to govern patching for the same reason." -- =================================================================== -- WHAT WAS MEASURED -- =================================================================== the-round-trip [ | test result | "emit a patch for two real edits to the tenancy agreement" "2 operations, at identity paths, applied and verified before writing" | "apply it to the document in this repository" "produces the edited document; digest matches :result" | "invert it, and apply the inverse to the result" "produces the original, byte for byte — `identical: 1 nodes compared`" | "apply it to a different document" "refused, naming both digests, having written nothing" | "a synthetic sequence case: one insert, two drops, two edits" "7 operations after the ordering fix; 1 wholesale replacement before it, with the reason reported" ] -- =================================================================== -- THE FALSIFICATION, RUN -- =================================================================== the-measurement-found-a-different-problem "SCRUTINY SAID: build the undo stack in one editor and count, because if the doubling is paid repeatedly over a whole document the design is wrong in practice whatever it is in principle. IT WAS BUILT AND COUNTED. tools/undospike types characters into a real document one at a time, as an editor would, and reports what the stack costs. Against examples/document-nihongo-article.umsg, 6,740 bytes encoded: characters typed one patch each coalesced ratio 50 18,850 377 50x 100 42,700 427 100x 200 105,400 527 200x 400 291,179 728 400x 800 902,779 1,128 800x THE DOUBLING IS NOT THE PROBLEM. A coalesced stack after 800 keystrokes is 1,128 bytes against a 6,740-byte document — a sixth of it, for the entire editing session, undoable to the first character. THE ACCUMULATION IS. One patch per keystroke stores O(n²) characters, because the twelfth patch carries an eleven-character string and a twelve-character one, and the eight-hundredth carries two of about eight hundred. Doubling the keystrokes quadruples the stack: 18,850 to 902,779 is the curve, and the ratio between the two columns is exactly n. SO THE LOAD-BEARING DECISION SURVIVES and a second one arrives beside it: carrying :was is affordable, and COALESCING IS NOT OPTIONAL. Those are different findings about different artefacts — the first is about the vocabulary and the second is about any consumer of it — and the scrutiny question conflated them." coalescing-belongs-to-the-consumer-and-not-the-vocabulary "SAID BECAUSE THE MEASUREMENT INVITES THE OPPOSITE CONCLUSION. It would be easy to read the quadratic as an argument for the vocabulary doing something about it — a compact form, an optional :was, a delta on strings. IT IS NOT. A patch is a value and says what one change did; how many changes a session keeps and which of them it merges is a policy about a session, and the same patch shape serves an editor that coalesces aggressively and a review tool that keeps every step. internal/undo coalesces consecutive :set operations at one path within a time window, and an editor that wants a different rule writes a different stack over the same patches." the-stack-round-trips [ | test result | "type 200 characters, then undo every change" "back to the starting document byte for byte, checked by digest and not by inspection" | "then redo every change" "forward again, same count" | "a new change after an undo" "abandons the redo branch, which is what every editor does and what keeps the history linear" | "the stack's own cost" "every patch encoded canonically and summed, which is the number in the table above" ] -- =================================================================== -- WHAT IS NOT YET RIGHT -- =================================================================== what-is-not-yet-right [ | gap "why it matters" | "a qualifier change replaces the whole node" "FOUND BY THE FIRST REAL TEST. Two repository documents differing only in their root identifier — @repo/scratch/v0 against v1 — produce ONE operation: a wholesale :set of the document. A qualifier is part of the value, so this is correct and it is coarse, and it makes patches nearly useless for documents that bump an identifier per version. The candidate fix is a path that addresses a node's qualifiers separately, since the encoding already separates them; it is not in this draft because inventing path syntax under time pressure is how a format acquires a wart" | "a path into a typed array is specified and not implemented" "the vocabulary defines it, internal/patch refuses it by name, and tools/umsgdiff replaces the whole array instead. A motion editor moving one control point is exactly the case it exists for" | ":move is specified and never emitted" "the differ has no move detection, so a moved element is a drop and an add. The vocabulary distinguishes them and nothing yet produces the distinction" | "composition is specified and not built" "two patches compose when the first's result is the second's base, and no tool does it. An undo stack that coalesces keystrokes needs it" | "no merge" "deliberately out of scope; see the vocabulary's not-here. It is the next thing, and it is a git merge driver rather than a system" ] -- =================================================================== -- SCRUTINY -- =================================================================== scrutiny { load-bearing "That carrying the displaced value on every operation is worth doubling the size of a patch. Everything else follows from it: the inverse exists, the precondition is free, and an applier cannot write into a document that drifted. IF IT IS WRONG — if patches are mostly transported rather than inverted, and the size dominates — then the right design is JSON Patch's, with :was optional and a separate test operation, and this vocabulary is carrying a cost for a benefit its users do not take." to-falsify "DONE, and the answer is in the-measurement-found-a-different-problem. A coalesced stack after 800 keystrokes is a sixth of the document it edits; an uncoalesced one is 134 times the document and growing quadratically. The doubling is affordable and the accumulation is not. WHAT WOULD STILL FALSIFY IT is a real editor whose changes do NOT coalesce — many small edits scattered across different paths, which is what a spatial editor dragging several objects looks like. That case is not measured here and undospike types into one string, which is the friendly shape." would-change "A measured fallback rate. tools/umsgdiff reports every patch it could not emit finely, and a rate above a few per cent over real documents would mean the ordering rules are still wrong somewhere and the fine-grained path is decoration. AND THE QUALIFIER COARSENESS, if it turns out to be common. If most real version-to-version changes bump an identifier, then most patches are wholesale replacements and the vocabulary is an expensive way to copy a document." already-rejected [ | approach "why not" | "a copy operation, as JSON Patch has" "it is an :add whose :now is a value the patch already carries. A second spelling of one fact, to save bytes a compressor would have saved anyway" | "null meaning deletion, as JSON Merge Patch has" "then a document containing null cannot be patched. :drop deletes, :set to null sets, and they invert differently" | "fuzzy or context-based application" "the reason patch(1) needs it is that it cannot identify its target. :base identifies it exactly" | "merge in the same vocabulary" "a merge is a function ON patches. Putting them together would mean nobody could use a patch without an opinion about concurrency, and concurrency is where every one of these designs gets hard" | "an elaborable rule rather than a label" "docs/56's claim three. It would give a document two sources for one fact — the rule and the operations — free to disagree, and the check that they agree is the whole design of a different vocabulary. :intent is a label and is never executed" | "operations addressed by identifier alone" "an element that has never been named cannot be patched, and most sequences in most documents are unkeyed. Position works everywhere and identity is better where it exists" ] } -- =================================================================== -- WHAT TO DO NEXT -- =================================================================== next [ | step do because | 1 "BUILT: internal/undo, and tools/undospike measuring it" "the falsification is run and the load-bearing decision survived it" | 2 "measure a scattered editing session, not a typed one" "undospike types into one string, which coalesces perfectly. Dragging six objects touches six paths and coalesces not at all, and that is the case the scrutiny is still open about" | 3 "patch composition, generally" "internal/undo folds consecutive :set at one path and nothing else. A paste followed by a style change is two patches where one would do" | 4 "the qualifier path, or a decision that the coarseness stands" "it is the one known defect that makes patches useless for a real class of document" | 5 "a git merge driver, over patches" "the last of docs/56's four wants, and the only one that still has nothing" ] }